tasks: stop task_create from overwriting the task it collides with - #286
Open
polyglotAI-bot wants to merge 1 commit into
Open
tasks: stop task_create from overwriting the task it collides with#286polyglotAI-bot wants to merge 1 commit into
polyglotAI-bot wants to merge 1 commit into
Conversation
A task ID is the date plus the title slugified and truncated to 40 characters, so two titles that differ only past that cut share one ID. task_create wrote it out regardless: write_text truncated the older task's markdown to the new body, and upsert_task rewrote that same row's title, status and content. Both copies of the older task were gone, and the tool still answered "Task created". The duplicate check does not cover this. It reports semantically similar tasks and offers confirm_duplicate=true, which was the one flag that skipped past the report straight into the overwrite -- so confirming a duplicate meant overwrite rather than "create a second task". _claim_task_id now walks base, base-2, base-3, ... and takes the first ID free of a row, of a done/ file, and of an exclusive create in active/. The row check keeps a new task from inheriting an existing one's history, the file checks hold when index and tree disagree, and O_EXCL closes the window between the check and the write. done/ IDs stay reserved, so a finished task is never resurrected as a new pending one. With every candidate taken the create is refused with is_error instead of resolved, and an upsert that fails after the file lands drops it again so the retry keeps the ID it was reaching for. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
polyglotAI-bot
force-pushed
the
polyglot/task-id-collision
branch
from
August 10, 2026 14:31
6ed626c to
69fc0a4
Compare
Contributor
Author
|
Rebased onto The conflict was in
Verification after the rebase:
Worth flagging for review scope: this PR fixes the collision in the agent tool handler. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A task ID is
<date>-<slug>, where the slug is the title lowercased, dashed andtruncated to 40 characters (
handlers/tasks.py:_make_task_id). Two titles thatdiffer only past that cut therefore produce the same ID on the same day — and
task_createwrote it out regardless:file_path.write_text(...)truncated the older task's markdown to the new body,taking its context and every
## Updatesnote with it;upsert_task(task_id=..., title=..., status=..., content=...)then rewrote thatsame row —
ON CONFLICT(id) DO UPDATE— with the new task's title, status andcontent.
Both copies of the older task were gone, the tool answered
Task created: <id>, andnothing logged a word. Hit in production: an in-progress task lost its body, its claim
note and its findings to a follow-up task whose title shared a long prefix.
The duplicate check ahead of it does not cover this. It reports semantically similar
tasks and tells the caller to retry with
confirm_duplicate=true— which was the oneflag that skipped the report and went straight into the overwrite. So
confirm_duplicatemeant "overwrite", not "create a second task". Two IDs were reachable this way that were
not even similar-looking: a completed task's ID (its row still holds it while its file
sits in
done/, so the new task took the row and orphaned the markdown), and the base<date>-that every punctuation-only title shares.Fix
_claim_task_idwalksbase,base-2,base-3, … and takes the first ID that is freeon all three counts, then hands back the ID it actually claimed:
upsert_taskwould otherwise rewrite that row in placedone/<id>.mdTaskManager.reindexcan never see one stem in both directoriesopen(path, "x")succeeds inactive/O_EXCL is what makes the last one atomic: the loser of a race gets
FileExistsErrorandmoves to the next suffix. Past 99 suffixes the create is refused with
is_error=Trueinstead of resolved. An
upsert_taskthat fails after the file lands unlinks it again,so a claimed ID is not burned by a half-done create — the retry keeps the ID it was
reaching for.
Nothing else in the repo reconstructs an ID from a title (
_make_task_idhas exactly onecaller), and IDs are opaque keys everywhere else — nothing parses the date prefix — so a
-2suffix is inert to every consumer. The same handler backs both the MCP tool andPOST /api/tasks, so both paths get the fix.One deliberate non-change:
POST /api/tasksreturns the tool result as HTTP 200 even whenis_erroris set. This adds the firstis_errorthat route can emit, but no route in therepo maps that field to a status code, so introducing 409 here would be a REST-contract
change belonging in its own PR.
Verification
16 new tests in
tests/test_task_id_collision.py, one behaviour each: 15 fail withoutthe fix, and the 16th is the control pinning that non-colliding titles keep their
unsuffixed IDs. Full suite
3030 passed(base 3014 + 16).Covered: the older task's file, row and FTS content survive; the response names the
claimed ID; suffixes keep walking to
-3; completed IDs stay reserved (both with andwithout a row); a row whose file is gone still holds its ID; a rival file appearing
inside the check window is not overwritten; two
asyncio.gathered creates land on twotasks; exhaustion refuses and writes nothing; a failed index write gives the ID back; and
the degenerate cases — creating straight into
done, a title whose own base is anothertask's
-2, punctuation-only titles, andctx.db is None.Seven mutants, each weakening one clause, are all killed — including replacing the
exclusive create with a preceding
path.exists()check, which only the in-window rivaltest catches.